|
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.Host;
using Microsoft.CodeAnalysis.LanguageServer.Handler.Diagnostics;
using Roslyn.Utilities;
namespace Microsoft.CodeAnalysis.LanguageServer.Handler
{
/// <summary>
/// Specialized cache used by the 'pull' LSP handlers. Supports storing data to know when to tell a client
/// that existing results can be reused, or if new results need to be computed. Multiple keys can be used,
/// with different computation costs to determine if the previous cached data is still valid.
/// </summary>
internal class VersionedPullCache<TCheapVersion, TExpensiveVersion>
{
private readonly string _uniqueKey;
/// <summary>
/// Lock to protect <see cref="_idToLastReportedResult"/> and <see cref="_nextDocumentResultId"/>.
/// This enables this type to be used by request handlers that process requests concurrently.
/// </summary>
private readonly SemaphoreSlim _semaphore = new(1);
/// <summary>
/// Mapping of a diagnostic source to the data used to make the last pull report which contains:
/// <list type="bullet">
/// <item>The resultId reported to the client.</item>
/// <item>The TCheapVersion of the data that was used to calculate results.
/// <para>
/// Note that this version can change even when nothing has actually changed (for example, forking the
/// LSP text, reloading the same project). So we additionally store:</para></item>
/// <item>A TExpensiveVersion (normally a checksum) checksum that will still allow us to reuse data even when
/// unimportant changes happen that trigger the cheap version change detection.</item>
/// </list>
/// This is used to determine if we need to re-calculate results.
/// </summary>
private readonly Dictionary<(Workspace workspace, ProjectOrDocumentId id), (string resultId, TCheapVersion cheapVersion, TExpensiveVersion expensiveVersion)> _idToLastReportedResult = new();
/// <summary>
/// The next available id to label results with. Note that results are tagged on a per-document bases. That
/// way we can update results with the client with per-doc granularity.
/// </summary>
private long _nextDocumentResultId;
public VersionedPullCache(string uniqueKey)
{
_uniqueKey = uniqueKey;
}
/// <summary>
/// If results have changed since the last request this calculates and returns a new
/// non-null resultId to use for subsequent computation and caches it.
/// </summary>
/// <param name="idToClientLastResult">a map of roslyn document or project id to the previous result the client sent us for that doc.</param>
/// <param name="projectOrDocumentId">the id of the project or document that we are checking to see if it has changed.</param>
/// <returns>Null when results are unchanged, otherwise returns a non-null new resultId.</returns>
public async Task<string?> GetNewResultIdAsync(
Dictionary<ProjectOrDocumentId, PreviousPullResult> idToClientLastResult,
ProjectOrDocumentId projectOrDocumentId,
Project project,
Func<Task<TCheapVersion>> computeCheapVersionAsync,
Func<Task<TExpensiveVersion>> computeExpensiveVersionAsync,
CancellationToken cancellationToken)
{
TCheapVersion cheapVersion;
TExpensiveVersion expensiveVersion;
var workspace = project.Solution.Workspace;
// We have to make sure we've been fully loaded before using cached results as the previous results may not be complete.
var isFullyLoaded = await IsFullyLoadedAsync(project.Solution, cancellationToken).ConfigureAwait(false);
using (await _semaphore.DisposableWaitAsync(cancellationToken).ConfigureAwait(false))
{
if (isFullyLoaded && idToClientLastResult.TryGetValue(projectOrDocumentId, out var previousResult) &&
previousResult.PreviousResultId != null &&
_idToLastReportedResult.TryGetValue((workspace, projectOrDocumentId), out var lastResult) &&
lastResult.resultId == previousResult.PreviousResultId)
{
cheapVersion = await computeCheapVersionAsync().ConfigureAwait(false);
if (cheapVersion != null && cheapVersion.Equals(lastResult.cheapVersion))
{
// The client's resultId matches our cached resultId and the cheap version is an
// exact match for our current cheap version. We return early here to avoid calculating
// expensive versions as we know nothing is changed.
return null;
}
// The current cheap version does not match the last reported. This may be because we've forked
// or reloaded a project, so fall back to calculating the full expensive version to determine if
// anything is actually changed.
expensiveVersion = await computeExpensiveVersionAsync().ConfigureAwait(false);
if (expensiveVersion != null && expensiveVersion.Equals(lastResult.expensiveVersion))
{
return null;
}
}
else
{
// Client didn't give us a resultId or we have nothing cached
// We need to calculate new results and store what we calculated the results for.
cheapVersion = await computeCheapVersionAsync().ConfigureAwait(false);
expensiveVersion = await computeExpensiveVersionAsync().ConfigureAwait(false);
}
// Keep track of the results we reported here so that we can short-circuit producing results for
// the same state of the world in the future. Use a custom result-id per type (doc requests or workspace
// requests) so that clients of one don't errantly call into the other.
//
// For example, a client getting document diagnostics should not ask for workspace diagnostics with the result-ids it got for
// doc-diagnostics. The two systems are different and cannot share results, or do things like report
// what changed between each other.
//
// Note that we can safely update the map before computation as any cancellation or exception
// during computation means that the client will never recieve this resultId and so cannot ask us for it.
var newResultId = $"{_uniqueKey}:{_nextDocumentResultId++}";
_idToLastReportedResult[(project.Solution.Workspace, projectOrDocumentId)] = (newResultId, cheapVersion, expensiveVersion);
return newResultId;
}
}
private static async Task<bool> IsFullyLoadedAsync(Solution solution, CancellationToken cancellationToken)
{
var workspaceStatusService = solution.Services.GetRequiredService<IWorkspaceStatusService>();
var isFullyLoaded = await workspaceStatusService.IsFullyLoadedAsync(cancellationToken).ConfigureAwait(false);
return isFullyLoaded;
}
}
}
|